You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1208 lines
46 KiB
1208 lines
46 KiB
/**
|
|
* Model Schema API Endpoint
|
|
*
|
|
* Fetches parameter schema for a specific model from its provider.
|
|
* Returns simplified parameter list for UI rendering.
|
|
*
|
|
* GET /api/models/:modelId?provider=replicate|fal|wavespeed
|
|
*
|
|
* Headers:
|
|
* - X-Replicate-Key: Required for Replicate models
|
|
* - X-Fal-Key: Optional for fal.ai models
|
|
* - X-WaveSpeed-Key: Optional for WaveSpeed models
|
|
*
|
|
* Response:
|
|
* {
|
|
* success: true,
|
|
* parameters: ModelParameter[],
|
|
* cached: boolean
|
|
* }
|
|
*
|
|
* WaveSpeed models fetch schemas dynamically from the /api/v3/models endpoint,
|
|
* with fallback to static definitions for models without api_schema.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { ProviderType } from "@/types";
|
|
import { ModelParameter, ModelInput } from "@/lib/providers/types";
|
|
import {
|
|
getCachedWaveSpeedSchema,
|
|
setCachedWaveSpeedSchema,
|
|
WaveSpeedApiSchema,
|
|
} from "@/lib/providers/cache";
|
|
|
|
// Cache for model schemas (10 minute TTL)
|
|
const schemaCache = new Map<string, { parameters: ModelParameter[]; inputs: ModelInput[]; timestamp: number }>();
|
|
const CACHE_TTL = 10 * 60 * 1000; // 10 minutes
|
|
|
|
// Image input property patterns
|
|
const IMAGE_INPUT_PATTERNS = [
|
|
"image_url",
|
|
"image_urls",
|
|
"image",
|
|
"images",
|
|
"image_input",
|
|
"input_image",
|
|
"first_frame",
|
|
"last_frame",
|
|
"tail_image_url",
|
|
"start_image",
|
|
"end_image",
|
|
"reference_image",
|
|
"init_image",
|
|
"mask_image",
|
|
"control_image",
|
|
];
|
|
|
|
// Text input properties
|
|
const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"];
|
|
|
|
// Properties that start with "image_" but are NOT image inputs
|
|
const IMAGE_PREFIX_EXCLUSIONS = ["image_size"];
|
|
|
|
// Parameters to filter out (internal/system params)
|
|
const EXCLUDED_PARAMS = new Set([
|
|
"webhook",
|
|
"webhook_events_filter",
|
|
"sync_mode",
|
|
"disable_safety_checker",
|
|
"go_fast",
|
|
"enable_safety_checker",
|
|
"output_format",
|
|
"output_quality",
|
|
"request_id",
|
|
]);
|
|
|
|
// Parameters we want to surface (user-relevant)
|
|
const PRIORITY_PARAMS = new Set([
|
|
"seed",
|
|
"num_inference_steps",
|
|
"inference_steps",
|
|
"steps",
|
|
"guidance_scale",
|
|
"guidance",
|
|
"negative_prompt",
|
|
"width",
|
|
"height",
|
|
"image_size",
|
|
"num_outputs",
|
|
"num_images",
|
|
"scheduler",
|
|
"strength",
|
|
"cfg_scale",
|
|
"lora_scale",
|
|
]);
|
|
|
|
interface SchemaSuccessResponse {
|
|
success: true;
|
|
parameters: ModelParameter[];
|
|
inputs: ModelInput[];
|
|
cached: boolean;
|
|
}
|
|
|
|
interface SchemaErrorResponse {
|
|
success: false;
|
|
error: string;
|
|
}
|
|
|
|
type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse;
|
|
|
|
/**
|
|
* Convert property name to human-readable label
|
|
*/
|
|
function toLabel(name: string): string {
|
|
return name
|
|
.replace(/_url$/, "")
|
|
.replace(/_/g, " ")
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
}
|
|
|
|
/**
|
|
* Check if property is an image input based on BOTH schema type AND name.
|
|
*
|
|
* Image inputs must be strings (URLs or base64) or arrays of strings.
|
|
* Integers, booleans, numbers with "image" in the name are NOT image inputs.
|
|
*/
|
|
function isImageInput(name: string, prop: Record<string, unknown>, schemaComponents?: Record<string, unknown>): boolean {
|
|
// First check: must be a string type (images are URLs or base64 strings)
|
|
// Integers, booleans, numbers are NEVER image inputs regardless of name
|
|
const resolved = resolvePropertyType(prop, schemaComponents);
|
|
const propType = resolved.type;
|
|
if (propType !== "string" && propType !== "array") {
|
|
return false;
|
|
}
|
|
|
|
// For arrays, check if items are strings (or unspecified - be lenient)
|
|
if (propType === "array") {
|
|
const items = prop.items as Record<string, unknown> | undefined;
|
|
// Only reject if items.type is explicitly specified AND not "string"
|
|
// Many schemas don't specify items type for image arrays
|
|
if (items && items.type && items.type !== "string") {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check exclusions (e.g., image_size is a parameter, not an image input)
|
|
if (IMAGE_PREFIX_EXCLUSIONS.includes(name)) {
|
|
return false;
|
|
}
|
|
|
|
// Check format hints (OpenAPI format field or resolved format) - strong signal for image URLs
|
|
const format = (prop.format ?? resolved.format) as string | undefined;
|
|
if (format === "uri" || format === "data-uri" || format === "binary") {
|
|
// Only treat as image if name also suggests it's an image
|
|
if (IMAGE_INPUT_PATTERNS.includes(name) ||
|
|
name.endsWith("_image") ||
|
|
name.startsWith("image_") ||
|
|
name.includes("_image_")) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check description for image-related keywords
|
|
const description = (prop.description as string || "").toLowerCase();
|
|
if (description.includes("image url") ||
|
|
description.includes("base64 image") ||
|
|
description.includes("data uri") ||
|
|
description.includes("image file") ||
|
|
description.includes("url of the image") ||
|
|
description.includes("path to image")) {
|
|
return true;
|
|
}
|
|
|
|
// Check explicit patterns (exact matches like "image_url", "image")
|
|
if (IMAGE_INPUT_PATTERNS.includes(name)) {
|
|
return true;
|
|
}
|
|
|
|
// More restrictive name pattern matching for strings
|
|
// Exclude names that suggest counts or settings rather than actual images
|
|
if (name.includes("_images") || // max_images, num_images
|
|
name.includes("guidance") || // image_guidance_scale
|
|
name.includes("generation") || // sequential_image_generation
|
|
name.includes("_count") || // image_count
|
|
name.includes("_size") || // image_size (already in exclusions but belt-and-suspenders)
|
|
name.includes("_scale")) { // image_scale
|
|
return false;
|
|
}
|
|
|
|
// Finally, check name patterns for remaining string types
|
|
return name.endsWith("_image") ||
|
|
name.startsWith("image_") ||
|
|
name.includes("_image_");
|
|
}
|
|
|
|
/**
|
|
* Check if property is a text input
|
|
*/
|
|
function isTextInput(name: string): boolean {
|
|
return TEXT_INPUT_NAMES.includes(name);
|
|
}
|
|
|
|
/**
|
|
* Resolve a $ref reference in OpenAPI schema
|
|
* E.g., "#/components/schemas/AspectRatio" -> schema object
|
|
*/
|
|
function resolveRef(
|
|
ref: string,
|
|
schemaComponents: Record<string, unknown>
|
|
): Record<string, unknown> | null {
|
|
// Parse reference path like "#/components/schemas/AspectRatio"
|
|
const match = ref.match(/^#\/components\/schemas\/(.+)$/);
|
|
if (!match) return null;
|
|
|
|
const schemaName = match[1];
|
|
const resolved = schemaComponents[schemaName] as Record<string, unknown> | undefined;
|
|
return resolved || null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the effective type and format from an OpenAPI property.
|
|
*
|
|
* Handles wrapper patterns used by code generators (e.g. Pydantic → OpenAPI):
|
|
* - anyOf / oneOf: picks the first non-null type (nullable pattern)
|
|
* - allOf: merges referenced schemas
|
|
* - $ref: resolves from schemaComponents
|
|
* - Direct type: returns immediately (fast path — no behavior change)
|
|
*/
|
|
function resolvePropertyType(
|
|
prop: Record<string, unknown>,
|
|
schemaComponents?: Record<string, unknown>
|
|
): { type?: string; format?: string } {
|
|
// Fast path: direct type is defined — existing behaviour, no change
|
|
if (prop.type !== undefined) {
|
|
return { type: prop.type as string, format: prop.format as string | undefined };
|
|
}
|
|
|
|
// anyOf / oneOf — pick the first non-null variant
|
|
const variants = (prop.anyOf ?? prop.oneOf) as Array<Record<string, unknown>> | undefined;
|
|
if (variants && Array.isArray(variants)) {
|
|
for (const variant of variants) {
|
|
// Resolve $ref inside variant
|
|
if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) {
|
|
const resolved = resolveRef(variant.$ref as string, schemaComponents);
|
|
if (resolved && resolved.type && resolved.type !== "null") {
|
|
return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined };
|
|
}
|
|
}
|
|
if (variant.type && variant.type !== "null") {
|
|
return { type: variant.type as string, format: (variant.format ?? prop.format) as string | undefined };
|
|
}
|
|
}
|
|
}
|
|
|
|
// allOf — merge referenced schemas
|
|
const allOf = prop.allOf as Array<Record<string, unknown>> | undefined;
|
|
if (allOf && Array.isArray(allOf) && schemaComponents) {
|
|
for (const item of allOf) {
|
|
if (item.$ref && typeof item.$ref === "string") {
|
|
const resolved = resolveRef(item.$ref as string, schemaComponents);
|
|
if (resolved && resolved.type) {
|
|
return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined };
|
|
}
|
|
}
|
|
if (item.type) {
|
|
return { type: item.type as string, format: (item.format ?? prop.format) as string | undefined };
|
|
}
|
|
}
|
|
}
|
|
|
|
// $ref at top level
|
|
if (prop.$ref && typeof prop.$ref === "string" && schemaComponents) {
|
|
const resolved = resolveRef(prop.$ref as string, schemaComponents);
|
|
if (resolved && resolved.type) {
|
|
return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined };
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Convert OpenAPI schema property to ModelParameter
|
|
*/
|
|
function convertSchemaProperty(
|
|
name: string,
|
|
prop: Record<string, unknown>,
|
|
required: string[],
|
|
schemaComponents?: Record<string, unknown>
|
|
): ModelParameter | null {
|
|
// Skip excluded parameters
|
|
if (EXCLUDED_PARAMS.has(name)) {
|
|
return null;
|
|
}
|
|
|
|
// Determine type and extract enum from allOf/$ref/anyOf/oneOf if present
|
|
let type: ModelParameter["type"] = "string";
|
|
let enumValues: unknown[] | undefined;
|
|
let resolvedDefault: unknown;
|
|
let resolvedDescription: string | undefined;
|
|
|
|
// Use resolvePropertyType() to handle anyOf/oneOf/allOf/$ref patterns
|
|
const resolved = resolvePropertyType(prop, schemaComponents);
|
|
const effectiveType = resolved.type;
|
|
|
|
if (effectiveType === "integer") {
|
|
type = "integer";
|
|
} else if (effectiveType === "number") {
|
|
type = "number";
|
|
} else if (effectiveType === "boolean") {
|
|
type = "boolean";
|
|
} else if (effectiveType === "array") {
|
|
type = "array";
|
|
}
|
|
|
|
// Extract enum/default/description from allOf with $ref
|
|
const allOf = prop.allOf as Array<Record<string, unknown>> | undefined;
|
|
if (allOf && allOf.length > 0 && schemaComponents) {
|
|
for (const item of allOf) {
|
|
const itemRef = item.$ref as string | undefined;
|
|
if (itemRef) {
|
|
const refResolved = resolveRef(itemRef, schemaComponents);
|
|
if (refResolved) {
|
|
if (Array.isArray(refResolved.enum)) {
|
|
enumValues = refResolved.enum;
|
|
}
|
|
if (refResolved.default !== undefined && resolvedDefault === undefined) {
|
|
resolvedDefault = refResolved.default;
|
|
}
|
|
if (refResolved.description && !resolvedDescription) {
|
|
resolvedDescription = refResolved.description as string;
|
|
}
|
|
}
|
|
} else if (Array.isArray(item.enum)) {
|
|
enumValues = item.enum;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract enum/default/description from anyOf/oneOf variants
|
|
const variants = (prop.anyOf ?? prop.oneOf) as Array<Record<string, unknown>> | undefined;
|
|
if (variants && Array.isArray(variants)) {
|
|
for (const variant of variants) {
|
|
if (variant.type === "null") continue;
|
|
// Resolve $ref inside variant
|
|
if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) {
|
|
const refResolved = resolveRef(variant.$ref as string, schemaComponents);
|
|
if (refResolved) {
|
|
if (Array.isArray(refResolved.enum) && !enumValues) {
|
|
enumValues = refResolved.enum;
|
|
}
|
|
if (refResolved.default !== undefined && resolvedDefault === undefined) {
|
|
resolvedDefault = refResolved.default;
|
|
}
|
|
if (refResolved.description && !resolvedDescription) {
|
|
resolvedDescription = refResolved.description as string;
|
|
}
|
|
}
|
|
} else {
|
|
if (Array.isArray(variant.enum) && !enumValues) {
|
|
enumValues = variant.enum;
|
|
}
|
|
if (variant.default !== undefined && resolvedDefault === undefined) {
|
|
resolvedDefault = variant.default;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const parameter: ModelParameter = {
|
|
name,
|
|
type,
|
|
description: (prop.description as string | undefined) || resolvedDescription,
|
|
default: prop.default !== undefined ? prop.default : resolvedDefault,
|
|
required: required.includes(name),
|
|
};
|
|
|
|
// Add constraints
|
|
if (typeof prop.minimum === "number") {
|
|
parameter.minimum = prop.minimum;
|
|
}
|
|
if (typeof prop.maximum === "number") {
|
|
parameter.maximum = prop.maximum;
|
|
}
|
|
|
|
// Use enum from property directly, or from resolved $ref
|
|
if (Array.isArray(prop.enum)) {
|
|
parameter.enum = prop.enum;
|
|
} else if (enumValues) {
|
|
parameter.enum = enumValues;
|
|
}
|
|
|
|
return parameter;
|
|
}
|
|
|
|
interface ExtractedSchema {
|
|
parameters: ModelParameter[];
|
|
inputs: ModelInput[];
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse schema from Replicate
|
|
*/
|
|
async function fetchReplicateSchema(
|
|
modelId: string,
|
|
apiKey: string
|
|
): Promise<ExtractedSchema> {
|
|
const [owner, name] = modelId.split("/");
|
|
|
|
const response = await fetch(
|
|
`https://api.replicate.com/v1/models/${owner}/${name}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Replicate API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Extract schema from latest_version.openapi_schema
|
|
const openApiSchema = data.latest_version?.openapi_schema;
|
|
if (!openApiSchema) {
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
// Navigate to Input schema
|
|
const inputSchema = openApiSchema.components?.schemas?.Input;
|
|
if (!inputSchema || typeof inputSchema !== "object") {
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
// Pass components.schemas for $ref resolution
|
|
const schemaComponents = openApiSchema.components?.schemas as Record<string, unknown> | undefined;
|
|
return extractParametersFromSchema(inputSchema as Record<string, unknown>, schemaComponents);
|
|
}
|
|
|
|
/**
|
|
* Fetch and parse schema from fal.ai using Model Search API
|
|
* Uses: GET https://api.fal.ai/v1/models?endpoint_id={modelId}&expand=openapi-3.0
|
|
*/
|
|
async function fetchFalSchema(
|
|
modelId: string,
|
|
apiKey: string | null
|
|
): Promise<ExtractedSchema> {
|
|
const headers: Record<string, string> = {};
|
|
if (apiKey) {
|
|
headers["Authorization"] = `Key ${apiKey}`;
|
|
}
|
|
|
|
// Use fal.ai Model Search API with OpenAPI expansion
|
|
const url = `https://api.fal.ai/v1/models?endpoint_id=${encodeURIComponent(modelId)}&expand=openapi-3.0`;
|
|
|
|
const response = await fetch(url, { headers });
|
|
|
|
if (!response.ok) {
|
|
// Return empty params if API fails so generation still works
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Response is { models: [{ openapi: {...}, ... }] }
|
|
const modelData = data.models?.[0];
|
|
if (!modelData?.openapi) {
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
const spec = modelData.openapi;
|
|
|
|
// Find POST endpoint with requestBody - paths are keyed by full endpoint path
|
|
let inputSchema: Record<string, unknown> | null = null;
|
|
|
|
for (const pathObj of Object.values(spec.paths || {})) {
|
|
const postOp = (pathObj as Record<string, unknown>)?.post as Record<string, unknown> | undefined;
|
|
const reqBody = postOp?.requestBody as Record<string, unknown> | undefined;
|
|
const content = reqBody?.content as Record<string, Record<string, unknown>> | undefined;
|
|
const jsonContent = content?.["application/json"];
|
|
|
|
if (jsonContent?.schema) {
|
|
const schema = jsonContent.schema as Record<string, unknown>;
|
|
|
|
// Handle $ref - resolve from components.schemas
|
|
if (schema.$ref && typeof schema.$ref === "string") {
|
|
const refPath = schema.$ref.replace("#/components/schemas/", "");
|
|
const resolvedSchema = spec.components?.schemas?.[refPath] as Record<string, unknown> | undefined;
|
|
if (resolvedSchema) {
|
|
inputSchema = resolvedSchema;
|
|
break;
|
|
}
|
|
} else if (schema.properties) {
|
|
inputSchema = schema;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!inputSchema) {
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
// Pass components.schemas for $ref resolution
|
|
const schemaComponents = spec.components?.schemas as Record<string, unknown> | undefined;
|
|
return extractParametersFromSchema(inputSchema, schemaComponents);
|
|
}
|
|
|
|
/**
|
|
* Extract ModelParameters and ModelInputs from an OpenAPI schema object
|
|
*/
|
|
function extractParametersFromSchema(
|
|
schema: Record<string, unknown>,
|
|
schemaComponents?: Record<string, unknown>
|
|
): ExtractedSchema {
|
|
const properties = schema.properties as Record<string, Record<string, unknown>> | undefined;
|
|
const required = (schema.required as string[]) || [];
|
|
|
|
if (!properties) {
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
const parameters: ModelParameter[] = [];
|
|
const inputs: ModelInput[] = [];
|
|
|
|
for (const [name, prop] of Object.entries(properties)) {
|
|
// Check if this is a connectable input (image or text)
|
|
// Pass both name AND prop to check schema type, not just name
|
|
if (isImageInput(name, prop, schemaComponents)) {
|
|
const resolvedType = resolvePropertyType(prop, schemaComponents).type;
|
|
inputs.push({
|
|
name,
|
|
type: "image",
|
|
required: required.includes(name),
|
|
label: toLabel(name),
|
|
description: prop.description as string | undefined,
|
|
isArray: resolvedType === "array",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isTextInput(name)) {
|
|
inputs.push({
|
|
name,
|
|
type: "text",
|
|
required: required.includes(name),
|
|
label: toLabel(name),
|
|
description: prop.description as string | undefined,
|
|
isArray: prop.type === "array",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Otherwise it's a parameter
|
|
const param = convertSchemaProperty(name, prop, required, schemaComponents);
|
|
if (param) {
|
|
parameters.push(param);
|
|
}
|
|
}
|
|
|
|
// Sort parameters: priority params first, then alphabetically
|
|
parameters.sort((a, b) => {
|
|
const aIsPriority = PRIORITY_PARAMS.has(a.name);
|
|
const bIsPriority = PRIORITY_PARAMS.has(b.name);
|
|
if (aIsPriority && !bIsPriority) return -1;
|
|
if (!aIsPriority && bIsPriority) return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
// Sort inputs: required first, then by type (image before text), then alphabetically
|
|
inputs.sort((a, b) => {
|
|
if (a.required !== b.required) return a.required ? -1 : 1;
|
|
if (a.type !== b.type) return a.type === "image" ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return { parameters, inputs };
|
|
}
|
|
|
|
/**
|
|
* Get hardcoded schema for Kie.ai models
|
|
* Kie.ai doesn't have a schema discovery API, so we define these manually
|
|
*/
|
|
function getKieSchema(modelId: string): ExtractedSchema {
|
|
// Common parameters for image models
|
|
const imageParams: ModelParameter[] = [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "4:3", "3:4", "16:9", "9:16"], default: "1:1" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
];
|
|
|
|
// Flux-2 aspect ratios (includes auto and additional ratios)
|
|
const flux2AspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "auto"];
|
|
|
|
// Model-specific schemas
|
|
const schemas: Record<string, ExtractedSchema> = {
|
|
// ============ Image models ============
|
|
"z-image": {
|
|
parameters: imageParams,
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"seedream/4.5-text-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"], default: "1:1" },
|
|
{ name: "quality", type: "string", description: "Output quality", enum: ["basic", "high"], default: "basic" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"seedream/4.5-edit": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"], default: "1:1" },
|
|
{ name: "quality", type: "string", description: "Output quality", enum: ["basic", "high"], default: "basic" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "image_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"gpt-image/1.5-text-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2"], default: "3:2" },
|
|
{ name: "quality", type: "string", description: "Output quality", enum: ["medium", "high"], default: "medium" },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"gpt-image/1.5-image-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2"], default: "3:2" },
|
|
{ name: "quality", type: "string", description: "Output quality", enum: ["medium", "high"], default: "medium" },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "input_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"flux-2/pro-text-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"flux-2/pro-image-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "input_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"flux-2/flex-text-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"flux-2/flex-image-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "input_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"nano-banana-pro": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2", "4:3", "16:9", "9:16", "21:9", "auto"], default: "1:1" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K", "4K"], default: "1K" },
|
|
{ name: "output_format", type: "string", description: "Output format", enum: ["png", "jpg"], default: "png" },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "image_input", type: "image", required: false, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"grok-imagine/text-to-image": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "1:1" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"grok-imagine/image-to-image": {
|
|
parameters: [],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "image_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
// ============ Audio/TTS models ============
|
|
"elevenlabs/turbo-v2.5": {
|
|
parameters: [
|
|
{ name: "voice_id", type: "string", description: "Voice ID to use for synthesis" },
|
|
{ name: "stability", type: "number", description: "Voice stability (0-1)", default: 0.5, minimum: 0, maximum: 1 },
|
|
{ name: "similarity_boost", type: "number", description: "Similarity boost (0-1)", default: 0.75, minimum: 0, maximum: 1 },
|
|
{ name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Text" }],
|
|
},
|
|
"elevenlabs/multilingual-v2": {
|
|
parameters: [
|
|
{ name: "voice_id", type: "string", description: "Voice ID to use for synthesis" },
|
|
{ name: "stability", type: "number", description: "Voice stability (0-1)", default: 0.5, minimum: 0, maximum: 1 },
|
|
{ name: "similarity_boost", type: "number", description: "Similarity boost (0-1)", default: 0.75, minimum: 0, maximum: 1 },
|
|
{ name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Text" }],
|
|
},
|
|
"elevenlabs/text-to-dialogue-v3": {
|
|
parameters: [
|
|
{ name: "stability", type: "number", description: "Voice stability (0-1)", default: 0.5, minimum: 0, maximum: 1 },
|
|
{ name: "similarity_boost", type: "number", description: "Similarity boost (0-1)", default: 0.75, minimum: 0, maximum: 1 },
|
|
{ name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Text / Dialogue Script" }],
|
|
},
|
|
"elevenlabs/sound-effect-v2": {
|
|
parameters: [
|
|
{ name: "duration_seconds", type: "number", description: "Duration in seconds (0.5-22)", minimum: 0.5, maximum: 22 },
|
|
{ name: "loop", type: "boolean", description: "Enable smooth looping", default: false },
|
|
{ name: "prompt_influence", type: "number", description: "How closely to follow the prompt (0-1)", default: 0.3, minimum: 0, maximum: 1 },
|
|
{ name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Sound Description" }],
|
|
},
|
|
// ============ Video models ============
|
|
"grok-imagine/text-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "2:3" },
|
|
{ name: "duration", type: "string", description: "Video duration in seconds", enum: ["6", "10"], default: "6" },
|
|
{ name: "mode", type: "string", description: "Generation mode", enum: ["fun", "normal", "spicy"], default: "normal" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"grok-imagine/image-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "2:3" },
|
|
{ name: "duration", type: "string", description: "Video duration in seconds", enum: ["6", "10"], default: "6" },
|
|
{ name: "mode", type: "string", description: "Generation mode", enum: ["fun", "normal", "spicy"], default: "normal" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "image_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"kling-2.6/text-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" },
|
|
{ name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" },
|
|
{ name: "sound", type: "boolean", description: "Enable sound generation", default: true },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"kling-2.6/image-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" },
|
|
{ name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" },
|
|
{ name: "sound", type: "boolean", description: "Enable sound generation", default: true },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "image_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"kling-2.6/motion-control": {
|
|
parameters: [
|
|
{ name: "mode", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "720p" },
|
|
{ name: "character_orientation", type: "string", description: "Character orientation source", enum: ["image", "video"], default: "video" },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "input_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
{ name: "video_urls", type: "image", required: true, label: "Video", isArray: true },
|
|
],
|
|
},
|
|
"kling/v2-5-turbo-text-to-video-pro": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" },
|
|
{ name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" },
|
|
{ name: "cfg_scale", type: "number", description: "Guidance scale", minimum: 0, maximum: 1, default: 0.5 },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "negative_prompt", type: "text", required: false, label: "Negative Prompt" },
|
|
],
|
|
},
|
|
"kling/v2-5-turbo-image-to-video-pro": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" },
|
|
{ name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" },
|
|
{ name: "cfg_scale", type: "number", description: "Guidance scale", minimum: 0, maximum: 1, default: 0.5 },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "negative_prompt", type: "text", required: false, label: "Negative Prompt" },
|
|
{ name: "image_url", type: "image", required: true, label: "Image" },
|
|
{ name: "tail_image_url", type: "image", required: false, label: "Tail Image" },
|
|
],
|
|
},
|
|
"wan/2-6-text-to-video": {
|
|
parameters: [
|
|
{ name: "duration", type: "string", description: "Video duration in seconds", enum: ["5", "10", "15"], default: "5" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "1080p" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"wan/2-6-image-to-video": {
|
|
parameters: [
|
|
{ name: "duration", type: "string", description: "Video duration in seconds", enum: ["5", "10", "15"], default: "5" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "1080p" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "image_urls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"wan/2-6-video-to-video": {
|
|
parameters: [
|
|
{ name: "duration", type: "string", description: "Video duration in seconds", enum: ["5", "10"], default: "5" },
|
|
{ name: "resolution", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "1080p" },
|
|
{ name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: false, label: "Prompt" },
|
|
{ name: "video_urls", type: "image", required: true, label: "Video", isArray: true },
|
|
],
|
|
},
|
|
"topaz/video-upscale": {
|
|
parameters: [
|
|
{ name: "upscale_factor", type: "string", description: "Upscale factor", enum: ["1", "2", "4"], default: "2" },
|
|
],
|
|
inputs: [
|
|
{ name: "video_url", type: "image", required: true, label: "Video" },
|
|
],
|
|
},
|
|
"veo3/text-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" },
|
|
{ name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"veo3/image-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" },
|
|
{ name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "imageUrls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
"veo3-fast/text-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" },
|
|
{ name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 },
|
|
],
|
|
inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }],
|
|
},
|
|
"veo3-fast/image-to-video": {
|
|
parameters: [
|
|
{ name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" },
|
|
{ name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 },
|
|
],
|
|
inputs: [
|
|
{ name: "prompt", type: "text", required: true, label: "Prompt" },
|
|
{ name: "imageUrls", type: "image", required: true, label: "Image", isArray: true },
|
|
],
|
|
},
|
|
};
|
|
|
|
return schemas[modelId] || { parameters: [], inputs: [] };
|
|
}
|
|
|
|
/**
|
|
* Get static schema for WaveSpeed models (fallback when dynamic schema not available)
|
|
*/
|
|
function getStaticWaveSpeedSchema(modelId: string): ExtractedSchema {
|
|
const modelIdLower = modelId.toLowerCase();
|
|
|
|
// Common image generation parameters for FLUX, SD3, etc.
|
|
const imageParams: ModelParameter[] = [
|
|
{
|
|
name: "num_inference_steps",
|
|
type: "integer",
|
|
description: "Number of denoising steps. More steps usually lead to higher quality but slower generation.",
|
|
default: 28,
|
|
minimum: 1,
|
|
maximum: 100,
|
|
},
|
|
{
|
|
name: "guidance_scale",
|
|
type: "number",
|
|
description: "Guidance scale for classifier-free guidance. Higher values follow the prompt more closely.",
|
|
default: 3.5,
|
|
minimum: 0,
|
|
maximum: 20,
|
|
},
|
|
{
|
|
name: "seed",
|
|
type: "integer",
|
|
description: "Random seed for reproducibility. Use -1 for random.",
|
|
default: -1,
|
|
},
|
|
{
|
|
name: "image_size",
|
|
type: "string",
|
|
description: "Output image dimensions",
|
|
default: "1024x1024",
|
|
enum: ["512x512", "768x768", "1024x1024", "1024x576", "576x1024", "1024x768", "768x1024", "1280x720", "720x1280"],
|
|
},
|
|
];
|
|
|
|
// Image inputs for image-to-image models
|
|
const imageInputs: ModelInput[] = [];
|
|
|
|
// Video model parameters (WAN, Kling, Luma, etc.)
|
|
const videoParams: ModelParameter[] = [
|
|
{
|
|
name: "num_frames",
|
|
type: "integer",
|
|
description: "Number of frames to generate",
|
|
default: 81,
|
|
minimum: 16,
|
|
maximum: 256,
|
|
},
|
|
{
|
|
name: "fps",
|
|
type: "integer",
|
|
description: "Frames per second for the output video",
|
|
default: 16,
|
|
minimum: 8,
|
|
maximum: 30,
|
|
},
|
|
{
|
|
name: "seed",
|
|
type: "integer",
|
|
description: "Random seed for reproducibility. Use -1 for random.",
|
|
default: -1,
|
|
},
|
|
{
|
|
name: "resolution",
|
|
type: "string",
|
|
description: "Output video resolution",
|
|
default: "480p",
|
|
enum: ["480p", "720p", "1080p"],
|
|
},
|
|
];
|
|
|
|
// Check if it's a video model
|
|
const isVideoModel =
|
|
modelIdLower.includes("wan") ||
|
|
modelIdLower.includes("video") ||
|
|
modelIdLower.includes("kling") ||
|
|
modelIdLower.includes("luma") ||
|
|
modelIdLower.includes("minimax") ||
|
|
modelIdLower.includes("t2v") ||
|
|
modelIdLower.includes("i2v");
|
|
|
|
// Check if it's an image-to-image model
|
|
const isImg2ImgModel =
|
|
modelIdLower.includes("kontext") ||
|
|
modelIdLower.includes("img2img") ||
|
|
modelIdLower.includes("edit") ||
|
|
modelIdLower.includes("inpaint") ||
|
|
modelIdLower.includes("controlnet");
|
|
|
|
if (isVideoModel) {
|
|
// For i2v models, add image input
|
|
if (modelIdLower.includes("i2v")) {
|
|
imageInputs.push({
|
|
name: "image", // i2v models typically use singular "image"
|
|
type: "image",
|
|
required: true,
|
|
label: "Input Image",
|
|
description: "Starting image for video generation",
|
|
});
|
|
}
|
|
return { parameters: videoParams, inputs: imageInputs };
|
|
}
|
|
|
|
// Image generation model
|
|
if (isImg2ImgModel) {
|
|
imageInputs.push({
|
|
name: "images", // WaveSpeed edit models expect "images" (plural array)
|
|
type: "image",
|
|
required: true,
|
|
label: "Input Image",
|
|
description: "Image to transform or edit",
|
|
isArray: true, // Signal that this should be sent as an array
|
|
});
|
|
|
|
// Add strength parameter for img2img
|
|
imageParams.push({
|
|
name: "strength",
|
|
type: "number",
|
|
description: "How much to transform the input image. 0 = no change, 1 = ignore input completely.",
|
|
default: 0.8,
|
|
minimum: 0,
|
|
maximum: 1,
|
|
});
|
|
}
|
|
|
|
return { parameters: imageParams, inputs: imageInputs };
|
|
}
|
|
|
|
// WaveSpeed API base URL
|
|
const WAVESPEED_API_BASE = "https://api.wavespeed.ai/api/v3";
|
|
|
|
/**
|
|
* Fetch WaveSpeed schema dynamically from cache or API
|
|
* Falls back to static schema if dynamic schema not available
|
|
*/
|
|
async function fetchWaveSpeedSchema(
|
|
modelId: string,
|
|
apiKey: string | null
|
|
): Promise<ExtractedSchema> {
|
|
// First check if we have a cached schema from the models list
|
|
const cachedSchema = getCachedWaveSpeedSchema(modelId);
|
|
if (cachedSchema) {
|
|
console.log(`[WaveSpeed Schema] Using cached schema for ${modelId}`);
|
|
const result = extractWaveSpeedSchema(cachedSchema, modelId);
|
|
if (result.parameters.length > 0 || result.inputs.length > 0) {
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// If no cache and we have an API key, try fetching the model directly
|
|
if (apiKey) {
|
|
try {
|
|
console.log(`[WaveSpeed Schema] Fetching schema for ${modelId} from API`);
|
|
const response = await fetch(`${WAVESPEED_API_BASE}/models`, {
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const models = data.models || data.data || data.results || [];
|
|
|
|
// Find the model by ID
|
|
const model = models.find((m: Record<string, unknown>) => {
|
|
const id = m.model_id || m.id || m.modelId || m.name;
|
|
return id === modelId;
|
|
});
|
|
|
|
if (model?.api_schema) {
|
|
// Cache the schema for future use
|
|
setCachedWaveSpeedSchema(modelId, model.api_schema as WaveSpeedApiSchema);
|
|
|
|
const result = extractWaveSpeedSchema(model.api_schema as WaveSpeedApiSchema, modelId);
|
|
if (result.parameters.length > 0 || result.inputs.length > 0) {
|
|
console.log(`[WaveSpeed Schema] Found dynamic schema with ${result.parameters.length} params, ${result.inputs.length} inputs`);
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[WaveSpeed Schema] Failed to fetch from API: ${error}`);
|
|
}
|
|
}
|
|
|
|
// Fall back to static schema
|
|
console.log(`[WaveSpeed Schema] Using static fallback for ${modelId}`);
|
|
return getStaticWaveSpeedSchema(modelId);
|
|
}
|
|
|
|
/**
|
|
* Extract parameters and inputs from WaveSpeed api_schema
|
|
* Schema structure: { api_schemas: [{ request_schema: { properties, required } }] }
|
|
*/
|
|
function extractWaveSpeedSchema(
|
|
apiSchema: WaveSpeedApiSchema,
|
|
modelId: string
|
|
): ExtractedSchema {
|
|
// WaveSpeed schema structure: api_schema.api_schemas[].request_schema
|
|
const apiSchemas = apiSchema.api_schemas;
|
|
if (!apiSchemas || !Array.isArray(apiSchemas) || apiSchemas.length === 0) {
|
|
console.log(`[WaveSpeed Schema] No api_schemas array found for ${modelId}`);
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
// Use the first schema (primary request schema)
|
|
const requestSchema = apiSchemas[0]?.request_schema;
|
|
if (!requestSchema || typeof requestSchema !== "object") {
|
|
console.log(`[WaveSpeed Schema] No request_schema found for ${modelId}`);
|
|
return { parameters: [], inputs: [] };
|
|
}
|
|
|
|
// Log the schema structure for debugging
|
|
const schemaKeys = Object.keys(requestSchema);
|
|
console.log(`[WaveSpeed Schema] Schema keys for ${modelId}: ${schemaKeys.join(", ")}`);
|
|
|
|
// Extract parameters using the shared extraction function
|
|
return extractParametersFromSchema(requestSchema as Record<string, unknown>);
|
|
}
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ modelId: string }> }
|
|
): Promise<NextResponse<SchemaResponse>> {
|
|
// Await params before accessing properties
|
|
const { modelId } = await params;
|
|
const decodedModelId = decodeURIComponent(modelId);
|
|
const provider = request.nextUrl.searchParams.get("provider") as ProviderType | null;
|
|
|
|
if (!provider || (provider !== "replicate" && provider !== "fal" && provider !== "kie" && provider !== "wavespeed")) {
|
|
return NextResponse.json<SchemaErrorResponse>(
|
|
{
|
|
success: false,
|
|
error: "Invalid or missing provider. Use ?provider=replicate, ?provider=fal, ?provider=kie, or ?provider=wavespeed",
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Check cache
|
|
const cacheKey = `${provider}:${decodedModelId}`;
|
|
const cached = schemaCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
return NextResponse.json<SchemaSuccessResponse>({
|
|
success: true,
|
|
parameters: cached.parameters,
|
|
inputs: cached.inputs,
|
|
cached: true,
|
|
});
|
|
}
|
|
|
|
try {
|
|
let result: ExtractedSchema;
|
|
|
|
if (provider === "replicate") {
|
|
// User-provided key takes precedence over env variable
|
|
const apiKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY;
|
|
if (!apiKey) {
|
|
return NextResponse.json<SchemaErrorResponse>(
|
|
{
|
|
success: false,
|
|
error: "Replicate API key required. Add REPLICATE_API_KEY to .env.local or configure in Settings.",
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
result = await fetchReplicateSchema(decodedModelId, apiKey);
|
|
} else if (provider === "kie") {
|
|
// Kie.ai uses hardcoded schemas (no schema discovery API)
|
|
result = getKieSchema(decodedModelId);
|
|
} else if (provider === "wavespeed") {
|
|
// WaveSpeed uses dynamic schemas from API, with static fallback
|
|
const apiKey = request.headers.get("X-WaveSpeed-Key") || process.env.WAVESPEED_API_KEY || null;
|
|
result = await fetchWaveSpeedSchema(decodedModelId, apiKey);
|
|
} else {
|
|
// User-provided key takes precedence over env variable
|
|
const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null;
|
|
if (!apiKey) {
|
|
return NextResponse.json<SchemaErrorResponse>(
|
|
{
|
|
success: false,
|
|
error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.",
|
|
},
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
result = await fetchFalSchema(decodedModelId, apiKey);
|
|
}
|
|
|
|
// Cache the result
|
|
schemaCache.set(cacheKey, { ...result, timestamp: Date.now() });
|
|
|
|
return NextResponse.json<SchemaSuccessResponse>({
|
|
success: true,
|
|
parameters: result.parameters,
|
|
inputs: result.inputs,
|
|
cached: false,
|
|
});
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
|
console.error(`[ModelSchema] Error fetching ${decodedModelId}: ${errorMessage}`);
|
|
return NextResponse.json<SchemaErrorResponse>(
|
|
{
|
|
success: false,
|
|
error: errorMessage,
|
|
},
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|